API Server 通常需要做到 2 種權限控制,第一是檢查使用者是否已經登入(Authentication),另一種是對已登入的使用者檢查是否具有足夠權限(Authorization),以 Express 的特性來看,在 Middleware 上面實作權限控制是最理想的,所以接下來將舉例兩個 Boilerplate 中實際用到的 Middlewares。
這個 Middleware 大家應該已經在前面的章節看過一兩次了,我們使用它來檢查 Request 中是否夾帶 JWT,如果有,則透過 Passport 的 JWT Strategy 取出目前 Request 的使用者:
const authRequired = (req, res, next) => {
passport.authenticate(
'jwt',
{ session: false },
handleError(res)((user, info) => {
handlePassportError(res)((user) => {
if (!user) {
res.pushError(Errors.USER_UNAUTHORIZED);
return res.errors();
}
req.user = user;
next();
})(info, user);
})
)(req, res, next);
};
未經認證卻想要存取資源的使用者將會收到 Errors.USER_UNAUTHORIZED
;經過認證的使用者的 Mongoose Model Instance 會被掛到 req 物件上,方便後續 Middleware 存取使用者資訊。
這個 Middleware 相依於 authRequired
,使用 roleRequired
之前一定要先使用 authRequired
,因為我們要依賴 authRequired
掛到 req 上的 user
物件。
roleRequired
比對目前使用者的角色 req.user.role
是否包含在指定的角色 requiredRoles
中:
const roleRequired = (requiredRoles) => (req, res, next) => {
if ((
requiredRoles instanceof Array &&
requiredRoles.indexOf(req.user.role) >= 0
) || (
req.user.role === requiredRoles
)) {
next();
} else {
return res.errors([Errors.PERMISSION_DENIED]);
}
};
權限不足者會收到 Errors.PERMISSION_DENIED
;權限正確的使用者不作任何處理,直接呼叫 next
進入後續 Middlewares。
有了以上權限控制的 Middlewares,就可以針對不同 API 實作不同的權限控管,例如只有已登入的 Admin 使用者才能存取 /api/users
這項資源:
import authRequired from '../middlewares/authRequired';
import roleRequired from '../middlewares/roleRequired';
// ...
app.get('/api/users',
authRequired,
roleRequired([Roles.ADMIN]),
userController.list
);
完整程式碼:src/server/routes/api.js
在我們 Boilerplate 中還使用了一個有趣的東西 Nonce
,這是網路安全領域中的術語,用來防止 Replay Attack。
Web Service 在某些情況下必須散播一次性的 Token,例如信箱驗證或是重設密碼,通常是讓使用者打開一串夾帶著 Token 的超連結,一旦驗證完成或是重設完成,該連結就應該要失效,也就是說該連結夾帶的 Token 是一次性的,不能被重複使用,所以我們在 Boilerplate 中必須要實作防護機制,這個機制正是利用 Nonce 來完成的。
以驗證信箱的功能為例,我們在資料庫中設有 nonce.verifyEmail
欄位,並且提供 toVerifyEmailToken
這個 Instance Method 產生驗證信箱的 Token,Token 中會包入 Nonce:
let UserSchema = new mongoose.Schema({
// ...
nonce: {
verifyEmail: Number,
},
});
UserSchema.methods.toVerifyEmailToken = function(cb) {
const user = {
_id: this._id,
nonce: this.nonce.verifyEmail,
};
const token = jwt.sign(user, configs.jwt.verifyEmail.secret, {
expiresIn: configs.jwt.verifyEmail.expiresIn,
});
return token;
};
完整程式碼:src/server/models/User.js
註:Instance Method 不能使用 Arrow Function,因為我們要在內部使用到this
當使用者註冊時,將 nonce.verifyEmail 設定為一個隨機數:
const user = User({
name: req.body.name,
// ...
nonce: {
verifyEmail: Math.random(),
},
});
再依據此 Nonce 產生 Token 寄送驗證連結至註冊信箱:
let token = user.toVerifyEmailToken();
nodemailerAPI()
.sendMail({
to: user.email.value,
subject: 'Email Verification',
html: renderToString(
<VerifyEmailMail token={token} />
),
})
.catch(err => { /* ... */ })
.then(info => { /* ... */ });
當使用者打開驗證連結後,要如何驗證 Token 是否被重複使用呢?我們只要將 Nonce 從 Token 中取出,並且和資料庫中儲存的 Nonce 比對就可以知道 Token 是第一次使用,還是被重複使用了:
const verifyUserNonce = (req, res, next) => {
let { _id, nonce } = req.decodedPayload;
User.findById(_id, handleDbError(res)((user) => {
if (nonce !== user.nonce.verifyEmail) {
return res.errors([Errors.TOKEN_REUSED]);
}
user.nonce.verifyEmail = -1;
next();
}));
};
驗證成功後,我們直接把 Nonce 清除為 -1(任何 Math.random() 無法產生的值皆可),如此一來,下一次如果收到夾帶同樣 Token 的 Request 時,Nonce 將會比對失敗,也就達到了防止 Replay Attack 的效果。